基于 Codemod 的前端代码迁移工具开发

发布于 | 分类于 前端/前端工程|本文包含AIGC内容

前端基础设施升级很少只是修改 package.json。以 React 18 和 Ant Design 4 升级为 React 18 和 Ant Design 5 为例,真正消耗时间的通常是散落在大量项目中的兼容性修改:组件属性更名、路由 API 变化、旧模块导入、样式覆盖失效,以及无法安全自动判断的边界场景。

这类工作如果完全依赖人工,会遇到三个问题:容易遗漏、修改口径不一致、迁移结果难以统计。Codemod 的价值,就是把已经确认的迁移规则固化成可重复执行的程序。

本文总结一个实际迁移工具的设计与实现。示例中的包名、组件名和配置均已泛化,不包含具体业务代码。

先澄清两个名称

  • Codemod 不是某个特定工具,而是一类“用程序批量修改代码”的工程方法。
  • jscodeshift 是 JavaScript/TypeScript 生态中常用的 Codemod 工具,它基于 Recast 提供 AST 查询和修改 API,并尽量保留原代码格式。

有时会把它们误写成 codemodejsshift,准确名称分别是 codemodjscodeshift

为什么不能只用字符串替换

假设要把组件的 overlay 属性改成 title

tsx
<Tooltip overlay={content}>帮助</Tooltip>

最简单的字符串替换看起来可行,但它无法可靠区分:

  • JSX 属性和普通对象字段;
  • 同名业务变量和组件 API;
  • 目标组件与用户自己封装的同名组件;
  • 注释、字符串和真正的代码;
  • 属性展开带来的间接传参。

AST 会先把源码解析成结构化语法树。规则可以只匹配 JSXElement 中名称为 Tooltip 的节点,再修改它的 JSXAttribute。这使“改什么”和“不改什么”都更明确。

不过 AST 也不是万能的。下面这种写法无法只根据局部语法判断 props 中是否包含旧属性:

tsx
<Tooltip {...props}>帮助</Tooltip>

因此迁移工具不能只有“自动替换”,还需要“发现问题并交给人工确认”的能力。

工具的目标与边界

这个工具最终形成了三类规则:

规则类型作用典型场景
auto-fix对确定性变更直接修改源码属性更名、导入路径替换、调用参数换序
report只定位风险,不修改源码组件 DOM 结构变化、运行时语义变化、属性展开
script修改项目级文件,不逐个遍历源码依赖版本、构建脚本、配置文件迁移

这里最重要的原则是:只有能够证明语义等价的修改才自动执行,其余情况只报告。

Codemod 负责减少机械劳动,不负责替开发者做业务判断,也不能替代类型检查、单元测试和人工回归。

整体架构

工具没有直接使用 jscodeshift 自带的 CLI,而是在它上面实现了一层迁移编排器。原因是单个 transform 只能解决一条规则,而真实升级需要:

  • 顺序执行多条规则;
  • 区分自动修复和人工审查;
  • 同时修改源码与项目配置;
  • 支持规则启停、排除目录和多源码目录;
  • 汇总 JSON、Markdown 和终端统计;
  • 记录某条规则修改了哪个文件、哪一行;
  • 在预览模式下运行完整流程但不写入源码。

简化后的目录如下:

text
migrate-tool/
├── bin/
│   └── cli.js
├── scripts/
│   ├── migrate.js
│   ├── prepare-cases.js
│   └── stats.js
├── transforms/
│   ├── auto-fix/
│   ├── report/
│   ├── script/
│   └── utils/
├── fixtures/
└── migrate.config.js

执行链路可以概括为:

text
CLI 参数 + 项目配置

动态发现并过滤规则

先执行项目级 script 规则

扫描目标源码文件

按文件串行执行 auto-fix 规则

基于变更后的源码执行 report 规则

写回源码(非 dry-run)

生成 JSON / Markdown 报告

为什么自动修复要按文件串行执行

同一个文件可能连续命中多条规则。前一条规则的输出必须作为后一条规则的输入:

js
let currentSource = source;

for (const rule of autoFixRules) {
  const result = rule.transform(
    { path: filePath, source: currentSource },
    { jscodeshift: j },
    options,
  );

  if (result.modified) {
    currentSource = result.source;
  }
}

如果每条规则都读取最初的源码,后执行规则会覆盖前面规则的结果。串行传递 currentSource 可以避免这个问题。

这也意味着规则顺序是迁移契约的一部分。如果规则 B 依赖规则 A 生成的新结构,就不能依赖文件系统的默认排序,应该显式声明顺序或依赖关系。

规则契约

自动修复规则

一条自动修复规则包含名称、类型、描述和转换函数:

js
module.exports = {
  name: "tooltip-overlay-to-title",
  type: "auto-fix",
  description: "将 Tooltip 的 overlay 属性迁移为 title",

  transform(fileInfo, api) {
    const j = api.jscodeshift;
    const root = j(fileInfo.source);
    const changes = [];

    root.find(j.JSXElement).forEach((path) => {
      const opening = path.value.openingElement;
      if (opening.name.type !== "JSXIdentifier") return;
      if (opening.name.name !== "Tooltip") return;

      const attribute = opening.attributes.find(
        (item) =>
          item.type === "JSXAttribute" &&
          item.name.name === "overlay",
      );

      if (!attribute) return;

      attribute.name.name = "title";
      changes.push({
        line: attribute.loc?.start.line ?? 0,
        message: "Tooltip: overlay → title",
      });
    });

    return {
      modified: changes.length > 0,
      source: changes.length > 0 ? root.toSource() : null,
      changes,
    };
  },
};

返回值中的 changes 不只是为了打印日志。它还是迁移的审计记录,可以回答:

  • 哪条规则执行了修改;
  • 修改发生在哪个文件和哪一行;
  • 一共修改了多少处;
  • 失败后应该优先检查哪些文件。

报告规则

报告规则只返回 findings

js
module.exports = {
  name: "dropdown-overlay-review",
  type: "report",
  description: "检查无法安全自动迁移的 Dropdown overlay",

  transform(fileInfo, api) {
    const j = api.jscodeshift;
    const root = j(fileInfo.source);
    const findings = [];

    root.find(j.JSXElement).forEach((path) => {
      // 省略具体匹配逻辑
      findings.push({
        line: path.value.loc?.start.line ?? 0,
        severity: "warning",
        message: "需要确认菜单数据能否转换为 items",
        suggestion: "检查事件回调、动态菜单和自定义渲染",
      });
    });

    return { findings };
  },
};

报告规则适合这些情况:

  • 新旧 API 不是一一映射;
  • 修改依赖运行时数据;
  • 需要理解跨文件类型或调用关系;
  • 组件 DOM 结构变化可能让定制样式失效;
  • React 18 自动批处理、并发渲染等语义变化;
  • 自动修复规则只处理了安全子集,剩余情况必须暴露出来。

自动修复规则也可以同时返回 findings。例如规则能处理显式属性,却发现了 {...props},这时应完成确定部分,并把不确定部分加入报告。

项目级脚本规则

并非所有迁移对象都是 JS/TS AST。package.json.npmrc、构建脚本等更适合单独处理:

js
module.exports = {
  name: "upgrade-project-config",
  type: "script",
  description: "升级依赖与项目配置",

  execute({ srcDir, dryRun, config }) {
    return {
      changes: [],
      findings: [],
      warnings: [],
    };
  },
};

这里应针对文件类型选择正确工具:JSON 应解析后修改对象,Shell 或 properties 文件至少要做精确锚点匹配,不能把所有文件都当成 JavaScript AST。

规则发现与配置

工具通过目录动态加载规则。新增规则只需放入对应目录,不需要修改中央注册表:

js
function loadRules(directory) {
  return fs
    .readdirSync(directory)
    .filter((file) => file.endsWith(".js"))
    .map((file) => require(path.join(directory, file)));
}

项目配置只保留编排层选项:

js
module.exports = {
  srcDir: ["src", "packages"],
  output: "reports/migration-report.json",
  exclude: [
    "**/*.test.*",
    "**/fixtures/**",
    "**/vendor/**",
  ],
  disabled: ["unsafe-experimental-rule"],
  // 调试时 only 的优先级高于 disabled
  // only: ["tooltip-overlay-to-title"],
};

CLI 提供 --src--mode--output--config--dry-run--show-rules 等参数。命令行参数应覆盖配置文件,以便在 CI 或临时调试时缩小范围。

JS/TS 的解析与转换

工具统一使用 TSX parser 处理 .js.jsx.ts.tsx

js
const j = jscodeshift.withParser("tsx");
const root = j(source);

这样可以覆盖 JSX、TypeScript 和装饰器等常见语法。但“一个 parser 处理所有 JS 方言”仍然有边界:Flow、遗留 Babel 插件语法、非标准装饰器配置都可能解析失败。一个更稳妥的设计是允许规则或文件类型声明 parser:

js
module.exports = {
  parser: "tsx",
  // ...
};

jscodeshift 官方也支持 transform 导出 babelflowtstsx 等 parser,或传入兼容 Recast 的自定义 parser。

自动修复规则如何分级

从实际规则中可以归纳出四个复杂度层次。

1. 局部语法替换

例如 JSX 属性更名、导入源替换。这类规则的输入和输出结构稳定,最适合自动修复。

tsx
// before
<Tooltip overlay={content} />

// after
<Tooltip title={content} />

2. 结构调整

例如把 require() 提升为顶层 import,或把一个路由配置拆成两项。这类规则需要处理作用域、变量冲突、导出语句和插入位置。

tsx
// before
const icon = require("./icon.svg");

// after
import icon from "./icon.svg";

结构调整不能只保证“能打印代码”,还要验证:

  • 新 import 是否重名;
  • 原变量是否在多个作用域中使用;
  • CommonJS 与 ESM 互操作是否一致;
  • 注释是否跟随正确节点;
  • 再运行一次是否保持不变。

3. 跨语句数据流改写

例如把路由注入的 props.match.params 改成 Hook。规则需要识别函数组件、参数解构、成员表达式、已有 Hook 导入、默认导出和高阶组件包装。

这类规则代码量会迅速增长。实际项目中,最复杂的单条规则超过两千行,这已经是一个明显信号:规则内部应该拆成“检测、规划、修改、验证”几个阶段,并为不同输入形态建立 fixture,而不是继续堆条件分支。

4. 运行时语义变化

React 18 自动批处理、render 阶段副作用、组件内部 DOM 变化,都不是简单的语法替换。它们更适合报告规则:先缩小人工排查范围,再由开发者判断真实语义。

报告设计

迁移工具同时生成机器可读的 JSON 和适合人工阅读的 Markdown。

json
{
  "summary": {
    "totalFiles": 120,
    "modifiedFiles": 18,
    "totalChanges": 46,
    "totalFindings": 23
  },
  "autoFix": {},
  "report": {},
  "errors": {
    "timeoutFiles": [],
    "errorFiles": [],
    "totalErrors": 0
  }
}

JSON 适合做二次统计或接入流水线,Markdown 应按规则和文件分组,至少包含:

  • 规则说明;
  • 文件路径与行号;
  • 原代码片段;
  • 风险等级;
  • 修改建议;
  • 解析失败和超时文件。

只打印“修改了 46 处”是不够的。迁移的真正交付物不是一批 diff,而是“修改结果 + 未自动处理的问题 + 失败清单”。

安全机制

Dry-run

--dry-run 应运行完整的扫描和规则链,只禁止写回源码。这样预览报告与真实执行使用的是同一套逻辑,不会出现“预览是一套代码,执行是另一套代码”。

需要注意:报告本身通常仍会写入磁盘,因此 dry-run 的准确含义是“不修改目标源码”,而不是“完全不产生文件”。

排除规则

默认排除 node_modules、构建产物和版本控制目录,并允许使用 glob 排除测试、fixture、vendor 或暂不迁移的目录。

排除逻辑必须基于相对路径统一处理,否则绝对路径、Windows 分隔符和多源码目录组合时容易失效。

错误隔离

单个文件解析失败不应中断整个仓库迁移。错误应记录规则名、文件名和错误类型,然后继续处理其他文件。

但错误隔离也不能把失败静默吞掉。只要存在解析失败,最终报告就必须显式展示,否则“扫描完成”会造成错误的安全感。

幂等性

一条合格的自动修复规则应该满足:

text
transform(transform(source)) === transform(source)

幂等性可以防止重复添加 import、重复包装组件或重复创建配置。它也是最值得加入自动化验证的属性之一。

测试迁移规则

迁移规则适合使用输入/输出 fixture,而不是只测试几个 AST 工具函数:

text
fixtures/
└── tooltip-overlay-to-title/
    ├── basic.input.tsx
    ├── basic.output.tsx
    ├── spread-props.input.tsx
    └── spread-props.report.json

每条规则至少覆盖:

  • 一个标准命中场景;
  • 一个不应修改的反例;
  • 一个语法边界场景;
  • 一个需要报告而不能自动修改的场景;
  • 连续执行两次的幂等性;
  • 注释和格式保留情况。

实际工具通过把只读样例复制到工作目录,保证每次本地演练都从干净输入开始。这个思路适合人工开发阶段,但长期仍应补充自动断言,否则只能证明“脚本跑完了”,不能证明输出正确。

当前规则覆盖了什么

在一次真实升级中,工具沉淀了 12 条自动修复规则、16 条报告规则和 2 条项目级脚本规则。为了避免绑定具体内部工程,这里只按能力分类:

类别处理内容
组件 API属性更名、废弃属性、组件结构调整
React 18自动批处理风险、render 阶段副作用
路由升级参数顺序、可选参数、注入 props 改 Hook
模块系统导入路径迁移、requireimport
样式兼容旧样式入口、组件定制样式风险
工程配置依赖版本、包管理器、构建配置

规则数量不是主要指标。更重要的是规则是否说明了安全边界,以及未覆盖场景是否进入报告。

Less/CSS 能否使用类似的工具

可以,但不能直接用 jscodeshift 解析 Less 或 CSS。

jscodeshift 面向 JavaScript/TypeScript AST。样式代码需要自己的 parser、AST visitor 和 stringifier。对 CSS、Less、SCSS 的源码级迁移,最合适的同类基础设施是 PostCSS

需求建议工具说明
CSS AST 修改postcss遍历 rule、at-rule、declaration 等节点
Less 源码修改postcss + postcss-less解析并重新输出 Less,不负责把 Less 编译为 CSS
SCSS 源码修改postcss + postcss-scss保留 SCSS 语法进行源码变换
只做规范检查Stylelint 自定义规则适合报告模式,也支持 fixable 规则
需要 Less 求值语义Less.js plugin适合编译前后处理,不一定适合保留原源码格式

PostCSS 官方将自己定位为 CSS 语法转换工具,并通过自定义 parser/stringifier 支持 Less、SCSS 等语法。postcss-less 的目标是让 PostCSS 操作 Less 源码,而不是编译 Less,这一点非常适合 Codemod。

当前实现中的样式处理缺口

实际项目已经写了几条样式报告规则,用正则检测 Less/CSS 中的旧 @import,以及检测可能受组件 DOM 变化影响的定制选择器。规则也声明了:

js
fileTypes: ["less", "css", "scss", "sass"]

但主扫描器只收集:

js
/\.(jsx?|tsx?)$/

因此这些样式文件不会进入规则执行链。这里不是“样式迁移已经支持”,而是“规则接口预留了样式类型,但扫描与解析链路尚未打通”。

此外,个别样式报告规则在检查扩展名之前就调用了 j(fileInfo.source)。即使把 .less 加入扫描范围,也会先尝试用 JavaScript parser 解析 Less 并失败。

修复不能只把扫描正则改成 /\.(jsx?|tsx?|less|css)$/,还需要把 parser 选择下沉到语言适配层。

一个统一的多语言迁移架构

比较合理的演进方式,是保留现有编排器,把“如何解析和打印”从规则执行中抽出来:

text
                    ┌─ JS/TS adapter ─ jscodeshift + Recast
文件扫描 → 语言路由 ├─ CSS adapter   ─ PostCSS
                    ├─ Less adapter  ─ PostCSS + postcss-less
                    └─ Config adapter─ JSON / 文本专用处理器

                    auto-fix / report 统一结果协议

规则声明自己支持的语言,而不是在函数内部猜测:

js
module.exports = {
  name: "replace-legacy-style-import",
  type: "auto-fix",
  languages: ["css", "less"],

  transform({ root }) {
    const changes = [];

    root.walkAtRules("import", (atRule) => {
      if (!atRule.params.includes("legacy-ui")) return;

      atRule.params = atRule.params.replace("legacy-ui", "modern-ui");
      changes.push({
        line: atRule.source?.start?.line ?? 0,
        message: "更新样式导入路径",
      });
    });

    return { modified: changes.length > 0, changes };
  },
};

Less adapter 可以这样处理源码:

js
const postcss = require("postcss");
const lessSyntax = require("postcss-less");

async function transformLess(source, from, plugins) {
  const result = await postcss(plugins).process(source, {
    from,
    syntax: lessSyntax,
    map: false,
  });

  return result.css;
}

在统一结果协议下,JS 和样式规则都返回:

ts
interface ITransformResult {
  modified: boolean;
  source?: string;
  changes?: IChange[];
  findings?: IFinding[];
}

编排器只关心结果,不关心底层是 jscodeshift 还是 PostCSS。

正则在样式迁移中的位置

正则不是完全不能用。检测单行、格式稳定的 @import,用正则实现成本很低。但一旦涉及这些情况,就应该转 AST:

  • 多行 @importurl()
  • 嵌套选择器和 &
  • Less 变量、mixin、guard;
  • 声明值中的函数和插值;
  • 需要修改选择器而不是只做提示;
  • 需要尽量保留注释和源码位置。

一个实用策略是:正则做低成本扫描和报告,AST 做可靠自动修复。

Stylelint 是否可以替代 PostCSS Codemod

Stylelint 的自定义规则本质上会接收 PostCSS Root,因此很适合实现“发现问题”的报告规则。它也支持可修复规则,但如果迁移需要:

  • 多步骤编排;
  • 同时修改 JS、样式和项目配置;
  • 输出统一迁移报告;
  • 管理规则先后依赖;

那么 Stylelint 更适合作为样式规则引擎之一,而不是整个迁移工具的编排层。

从实现中暴露出的工程问题

1. 文件扫描与规则声明脱节

规则声明支持样式文件,扫描器却只找 JS/TS。扫描范围应该由已启用规则的 languagesfileTypes 合并得出,而不是硬编码在主流程中。

2. mode 与脚本规则边界不够清晰

allauto-fixreport 控制了逐文件规则,但项目级脚本在文件扫描前统一执行。如果用户只想生成报告,脚本规则仍可能修改配置文件。

更安全的设计是增加独立的 script mode,或要求项目级规则也明确声明 mutatesFiles,并统一受 dry-run 和 mode 控制。

3. 超时不是强制中断

主流程记录了单文件时间预算,但转换函数是同步执行的。只能在规则开始前检查已经消耗的时间,无法中断一条正在阻塞的规则。

如果确实需要强制超时,应把文件处理放进 Worker Thread 或子进程,在超时后终止 worker。否则更准确的名称应是“耗时预算告警”。

4. 主执行文件职责过多

主脚本同时负责 CLI 参数、配置合并、规则加载、文件扫描、parser 路由、规则执行、错误收集和 Markdown 生成。随着语言和规则增加,它会成为新的维护瓶颈。

可以按职责拆为:

text
cli → config → scanner → language adapters → rule runner → reporter

这是实现层拆分,不需要引入复杂框架。每层保持一个清晰输入输出即可。

5. 缺少显式规则顺序

动态读取目录很方便,但文件名排序不应成为隐含依赖。建议规则增加:

js
{
  name: "rule-b",
  after: ["rule-a"],
}

加载阶段做拓扑排序,并在循环依赖时直接失败。

6. 报告规则看到的是自动修复后的源码

当前执行顺序是先 auto-fix,后 report,因此报告针对的是“迁移后的剩余问题”。这通常是合理的,但行号已经可能不同于原始源码。

报告中应明确记录阶段,或者保留原始位置映射。否则开发者拿 dry-run 报告对照未修改文件时,行号可能出现偏差。

推荐的落地顺序

如果从零开发一个类似工具,可以按以下顺序推进:

  1. 先收集迁移清单,把规则分为“可自动修复”和“只报告”。
  2. 建立统一规则协议和 fixture,不急着做复杂 CLI。
  3. 先实现 2~3 条局部 AST 规则,验证解析、打印和幂等性。
  4. 增加 dry-run、排除目录、错误隔离和 JSON 报告。
  5. 再加入 Markdown 报告、规则筛选和项目级脚本。
  6. 需要处理样式时,引入语言 adapter,不要让 jscodeshift 强行解析所有文件。
  7. 最后再处理跨语句、跨文件的复杂规则;收益低或误判高的场景保持报告模式。

总结

一个可用的 Codemod 工具不只是若干 AST 替换脚本。它至少包含四层能力:

  1. 语言层:为 JS/TS、CSS/Less 和配置文件选择正确 parser;
  2. 规则层:明确自动修复、人工报告和项目脚本的边界;
  3. 编排层:控制规则顺序、文件范围、dry-run 和错误隔离;
  4. 交付层:输出可审计的修改记录、剩余问题和失败清单。

jscodeshift 适合处理 JavaScript/TypeScript,PostCSS 加 postcss-less 适合处理 Less/CSS。二者不需要互相替代,更适合被同一个迁移编排器统一调度。

Codemod 最重要的价值也不是“自动改得越多越好”,而是把迁移知识变成明确、可重复、可审查的规则,并把无法安全判断的部分如实交还给人。

参考资料

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。